Skip to content

feat(tools): add export_gap, which reports the exports a library lacks - #8588

Open
juanpimg wants to merge 5 commits into
boykopovar:mainfrom
juanpimg:feat/export-gap-tool
Open

juanpimg wants to merge 5 commits into
boykopovar:mainfrom
juanpimg:feat/export-gap-tool

Conversation

@juanpimg

@juanpimg juanpimg commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds tools/export_gap.py, which reports the system library exports that AnyPS5 does not provide.

AnyPS5 resolves a title's imports by NID, so a title that imports a system function that no AnyPS5 library provides fails to load with an unresolved import. The pull requests that declare those exports — #8021, #8028, #8330, #8331, #8333, #7795, #8240, #8328, #6584, #8255 — all build the same list by hand:

The list is the intersection of the export table of the console's library (function symbols only), the pinned aerolib names and the functions not yet defined anywhere in the repo. All the names recompute to their NIDs with tools/nid_names.py.

The tool computes that list and writes it in the format tools/stubgen.py --nids takes, so the whole job becomes two commands:

python3 tools/export_gap.py --module libScePad --export-table <dir> --db <aerolib.csv> --out missing.txt
python3 tools/stubgen.py --module libScePad --nids missing.txt --db <aerolib.csv>

It recognises what a module already provides in all five ways this project does it: an APS5_VABI declaration, a name ending in _nid_postfix (whose NID is the one of the base name), an APS5_EXPORT binding, a symbol produced by a macro, and a definition emitted by APS5_ASM_FUNCTION or APS5_SIGNAL_ASM_FUNCTION.

Two things it gets right that a hand-built list does not, and that cost real pull requests while this tool was being written:

  • A library can declare its exports through a macro. libSceVoiceChat declares sixteen of them through VOICECHAT_OFFLINE_REQUEST(NAME). A text scan of APS5_VABI does not see them, so the list counts them as missing and a pull request that declares them again exports the same NID twice. The tool reports all sixteen as already provided.
  • A declaration is a global symbol. Declaring a name that the library itself calls makes the library's own call bind to the new declaration. core/libs/prx/libScePosixForWebKit/Resolver.cpp:186 calls the host's ::gethostbyname_r, so declaring gethostbyname_r in that library broke its resolver and guest_resolver failed with "gethostbyname_r not implemented". The tool reports that export as needing a renamed helper bound with APS5_EXPORT, the way libSceAmpr re-exports the libc functions, with the file and the line of the call.

It also reports the NIDs the database does not carry, which the maintainer's rule leaves out of the list.

Tested

CachyOS Linux, kernel 7.2.9-2-cachyos, Python 3.14.8, Release build.

  • New ctest entry export_gap: 39 test cases, all passing, no network access. The cases cover a module whose exports are all missing, a module that provides an export under each of the three postfixes, a module that provides an export through a macro, a module that emits a definition with APS5_ASM_FUNCTION (with and without an APS5_EXPORT alias), a module that binds a NID explicitly with APS5_EXPORT, a NID the database does not carry, a missing export the module calls itself, the same result from the --nids input as from the --export-table input, and the error paths.
  • python3 tools/check_conventions.py --base origin/main: all conventions checks passed.
  • cmake --build build --parallel: no error.
  • ctest --test-dir build -R export_gap --output-on-failure: 1/1 passed.
  • The two real cases the tool exists for:
    • libSceVoiceChat reports 0 missing, 30 already provided, and names the sixteen macro-produced symbols as provided through the macro.
    • libScePosixForWebKit reports 24 missing, 12 already provided and names gethostbyname_r as called at core/libs/prx/libScePosixForWebKit/Resolver.cpp:186.

No title was run: the contributor has no PS5 and no game dumps.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 1835 CMakeLists.txt
PR 5021 CMakeLists.txt
PR 7642 CMakeLists.txt
PR 10193 CMakeLists.txt
PR 10897 CMakeLists.txt
PR 13578 CMakeLists.txt
PR 13928 CMakeLists.txt
PR 13929 CMakeLists.txt
PR 14212 CMakeLists.txt

@github-actions github-actions Bot added the build label Oct 20, 2026
@juanpimg
juanpimg force-pushed the feat/export-gap-tool branch from 06a6490 to edc4312 Compare October 20, 2026 21:22
@juanpimg
juanpimg force-pushed the feat/export-gap-tool branch 2 times, most recently from 6de072e to 2fadfee Compare October 21, 2026 20:06

@jotitadev jotitadev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export_gap does not run on Python 3.9 (the repo's floor).

Machine: Apple M2 Max, macOS 27.2. Pythons: /usr/bin/python3 = CPython 3.9.6 (Xcode), and 3.14.7.
PR head: 2fadfee, run as python3 tools/tests/test_export_gap.py.

  • CPython 3.14.7: OK (27 tests).
  • CPython 3.9.6: FAILED (errors=22), every one:
    TypeError: write_text() got an unexpected keyword argument 'newline'
    at tools/export_gap.py:321 and :323:
    destination.write_text("".join(nid + "\n" for nid in missing_nids), encoding="utf-8", newline="\n")
    ...
    destination.write_text("", encoding="utf-8", newline="\n")

Path.write_text's newline keyword was added in Python 3.10, so on 3.9 the tool raises before
writing anything. CI did not catch it because the runners use Python 3.12/3.13, not 3.9.

To keep the explicit-LF behavior on 3.9, write through open instead, e.g.:

with open(destination, "w", encoding="utf-8", newline="\n") as handle:
    handle.write("".join(nid + "\n" for nid in missing_nids))

Written with AI assistance (Claude Code). I ran these Python tests on my machine.

@oneandonlydean oneandonlydean self-assigned this Oct 23, 2026
@oneandonlydean

Copy link
Copy Markdown
Collaborator

@juanpimg export_gap.py:321/:323 uses Path.write_text(..., newline=...), which requires Python 3.10, while the registered test still permits Python 3.9. Could you use Path.open/open with newline followed by write for both empty and nonempty output, then run the complete export_gap suite on Python 3.9 and a current Python version?

@THEMANnt THEMANnt left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 2fadfee89939 against author merge base 6f9de5738615. Requesting changes because the new gap report incorrectly treats unpatched helper names as providers of a hashed NID.

Do not count _nid_no_patch and _nid_no_patch_cut as hashed exports — tools/export_gap.py:124–128, 167–175. base_of strips all three suffixes, then collect_provided hashes the base name. However, the exact repository resolver in core/libs/nid/src/NidResolver.cpp:15–20,45–51 keeps _nid_no_patch names unchanged and strips _nid_no_patch_cut to a literal name without hashing. Only _nid_postfix has the hashed-base behavior. With a console table requesting T2IWNGg3cnI (sceExampleNoPatchBase) and a source defining only sceExampleNoPatchBase_nid_no_patch, the submitted Python tool reports 0 missing, 1 already provided; the cut variant produces the same false result. Thus the emitted stub list omits an export that remains unresolved. This is reproduced in disposable source/table/database fixtures; resolver semantics are verified directly from the pinned source. Please model the actual resolver and correct the test that currently expects both unpatched variants to satisfy this NID. Preserve explicit APS5_EXPORT bindings as valid providers.

Windows x64 / CPython 3.11: all 27 export-gap tests, 37 NID-name tests and 16 stub-generator tests pass. Supporting suites also pass on the author merge base. The new suite cannot run there because the new module does not exist; that import error is not regression-sensitivity evidence. Conventions, whitespace and registration pass. Hosted relinker matrix/ASan and conventions jobs succeed; full product jobs are skipped. An attempted standalone native resolver control did not link because its dependency set was incomplete; no successful native execution is claimed and those link errors are not author defects.

@babyhuey

Copy link
Copy Markdown
Contributor

Windows run of this head 2fadfee8993, merged onto main 710cf9c0352 (clean merge, local only), with the selection CI's skipped Windows product job uses: Windows 11, Ryzen 7 5800X, RTX 3080, WinLibs GCC 15.2, Release, BUILD_TESTING=ON. The tree was configured fresh after the merge, then select_tests.py select / ninja -f select.ninja selected / --target libs / select_tests.py ctest --parallel 4 --timeout 120, with check_skips.py --job windows.

  • The change touches the top-level CMakeLists.txt, so select runs the full suite (2,399 tests): 1,707 pass, 3 fail, 689 skip. The 3 failures also fail on 710cf9c0352 itself on this machine: pr_overlap_encoding, agc_host_import_cut_lifetime (agc_host_import_cut_lifetime fails on Windows since the merge of #9159 #10746), and windows_trace_imports, whose relinked output.exe Windows Defender removes here as a false-positive detection. No failures from this PR.
  • The new export_gap passes.

AI-assisted: yes (Claude Code)

@juanpimg

Copy link
Copy Markdown
Contributor Author

Thanks — fixed. Path.write_text(..., newline=...) is gone; output now goes through open(destination, "w", encoding="utf-8", newline="\\n"), which works on 3.9 and keeps the explicit LF behavior. The new tests cover it (32 pass here on 3.14; the call pattern is 3.9-safe by inspection — no 3.9 runtime on this machine to execute it on).

@juanpimg

Copy link
Copy Markdown
Contributor Author

Thanks — fixed as requested. Only _nid_postfix hashes the base now; _nid_no_patch resolves to itself and _nid_no_patch_cut to its stripped base, matching NidResolver.cpp:56-66, and neither is ever hashed. New tests pin all three behaviors. tools/pr_overlap.py reports no shared export with any open PR (only uncompared CMakeLists.txt entries from base-conflicting PRs, which this change does not touch semantically beyond its own 2-line test registration).

AnyPS5 resolves a title's imports by NID, so a title that imports a system
function that no AnyPS5 library provides fails to load with an unresolved
import. The pull requests that declare those exports build the same list by
hand: the export table of the console's library, intersected with the pinned
aerolib names, minus what the repository already defines.

export_gap computes that list. It recognises what a module already provides
in all four ways the project does it: an APS5_VABI declaration, a name
ending in _nid_postfix, whose NID is the one of the base name, an
APS5_EXPORT binding, and a symbol produced by a macro. It reports the NIDs
the database does not carry, which the maintainer's rule leaves out, and the
missing exports whose name the module calls itself, which must not be
declared under that name because the declaration would interpose on the
module's own call.

The output is the NID file tools/stubgen.py takes.
The console's export table carries a C++ mangled symbol, _Z5dummyv, whose
name cannot be declared as a C function. Listing it as an ordinary missing
export would lead a contributor to declare it under that name, so it now
goes in the category that says the export needs a renamed helper bound with
APS5_EXPORT. A name that starts with an underscore but is not mangled stays
an ordinary missing export, and the NID list stays complete, because
tools/stubgen.py binds a NID it cannot declare under the real name with its
UnknownNN helper.

The module name check also accepts the libraries whose name ends in .native,
which are real directories under core/libs/prx, and still rejects a name with
a path separator.
… working

_nid_no_patch resolves to itself and _nid_no_patch_cut to its stripped
base; only _nid_postfix hashes. Write output files with open(..., newline)
so Python 3.9 works.
@juanpimg
juanpimg force-pushed the feat/export-gap-tool branch from 176a618 to c4563f1 Compare October 27, 2026 13:57
@juanpimg

Copy link
Copy Markdown
Contributor Author

Update: rebased onto current origin/main (conflict-free) and applied a second fix round. _nid_disambig<N> with an all-digit tail is now stripped after the postfix strip, mirroring StripNidPostfix — verified against fstat_nid_disambig1_nid_postfix/pwrite_nid_disambig1_nid_postfix (libkernel) and sceAjmBatchJobRunBufferRa_nid_disambig1 (libSceAjmi); the previously reported false missings are gone. Tool suite: 36 tests pass. tools/pr_overlap.py reports no shared export with any open PR.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 27, 2026

@THEMANnt THEMANnt left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed c4563f10df5a5900a0c2f210a112585e42f78de4. The earlier suffix-resolution finding is fixed: independent fixtures now match the pinned resolver for both literal no-patch variants, hashed _nid_postfix, and numeric disambiguators.

[P2] Protect export-table inputs from --out — tools/export_gap.py:322–324,349–354. The existing alias guard checks --nids and --db, but omits the selected <export-table>/<module>.c files. Running --module libSceExample --export-table tables --db names.txt --out tables/libSceExample.c reads a valid table, then opens that same input with "w", replaces its contents with T2IWNGg3cnI\n, and exits 0. This silently destroys the export inventory. The same fixture with a separate output preserves the table; the existing database-alias guard correctly refuses its analogous case.

Please include every selected export-table file in the input/output alias validation before writing, including resolved-path and hardlink aliases, and add preservation regressions.

Windows/Python 3.11: export-gap 36/36, NID-name 45/45 and stub-generator 23/23 passed. The independent reproduction runs a byte-identical copy of the submitted CLI in a disposable fixture. The hosted Windows relinker job merged this head and passed export_gap; that suite does not cover this alias. Initial supporting-suite sparse-checkout/sandbox failures were corrected; no product defect is inferred from them. No game execution was performed.

@blawesom

Copy link
Copy Markdown
Contributor

Static review plus an independent Linux run of the full Python test module (CachyOS, Python 3.14) and tools/check_conventions.py. No findings.

What I checked (static)

  • New standalone tool tools/export_gap.py (375 lines): reads a genstub-style sprx_dlsym(__handle, "NID") export table or a bare NID list, intersects with tools/nid_names.py's pinned name database and with the NIDs already defined in core/libs/prx/*, and emits tools/stubgen.py --nids-compatible output for what a library is missing. That matches the file's stated purpose and its neighboring tools' conventions (root-relative PRX scan, module-name validation, mangled-name rejection in helper_reason).
  • Input validation is explicit rather than silent: unparseable module names rejected (is_safe_module), non-C-identifier rejects enumerated with reasons, _Z-mangled names called out as C++ rather than leaking into output.
  • Registration: add_test(NAME export_gap ...test_export_gap.py) added to the top-level CMake BUILD_TESTING block beside the other tools/tests Python entries, same drive/script pattern, consistent with annotate_module/unwrap_self/audit_dump above it.

Test run

cd tools && python3 tests/test_export_gap.py
tree result
this head 36/36 Passed (0.017s) — unittest plain, OK

tools/check_conventions.py --base bb04a4f1ea --head passes. Not an AGC/shader/VideoOut change — no paired VVL run needed.

Coverage note

36 tests in 0.017s suggests table-driven fixtures over the parser/intersector (likely one per input-corruption class) rather than an end-to-end run against a real library's export table. That is the right unit of testing for a report-only tool; a real --nids round-trip through stubgen.py against one library would be the higher-signal check but is not required to trust this change — nothing it emits is authoritative until stubgen accepts it anyway.

@juanpimg

Copy link
Copy Markdown
Contributor Author

While scoping the remaining libSceLibcInternal gap I found a precision bug in the tool: exports defined in assembly with APS5_ASM_FUNCTION / APS5_SIGNAL_ASM_FUNCTION were not counted as provided.

Examples in the tree: setjmp_nid_postfix and longjmp_nid_postfix in core/libs/prx/libc/src/JumpBuffer.cpp, Aps5FiberSwitchStack_nid_no_patch and Aps5FiberTrampoline_nid_no_patch in libSceFiber/Export.cpp, and the pthread/signal/context entry points in libkernel. The tool could therefore report a NID as missing for a module that already serves it through libc (setjmp/longjmp are the concrete cases), which is exactly the kind of report that leads a contributor to declare a throwing stub over a real implementation.

Fixed in a510983b85: collect_provided now folds nid_names.source_assembly_definitions through the same postfix logic as APS5_VABI (_nid_postfix hashes the base, _nid_no_patch/_nid_no_patch_cut stay literal so an APS5_EXPORT alias can match). Three tests cover the postfix, no-patch and no-patch-plus-alias cases. 39/39 tests pass and tools/check_conventions.py --base origin/main is clean.

This does not change the output for modules without assembly definitions.

MarcZX10 added a commit to MarcZX10/AnyPS5 that referenced this pull request Nov 1, 2026
…e_diff

The registration sat directly after the annotate_module block. PR boykopovar#8588 adds export_gap at the same line, so the two pull requests would conflict textually. The test has no other dependency, so it moves to a position that no open pull request's CMake hunk touches.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants